适用场景

飞书 CLI 内置了一套扩展机制,让你在不修改 CLI 源码的情况下,替换或增强 CLI 的核心行为。

如果你是企业内部开发者或 ISV,希望在自己的 Agent 或应用中直接内嵌飞书 CLI 能力,并需要:

  • 从自己的凭证系统(数据库、Vault、配置中心)获取 AppID / Token 等

  • 对所有 HTTP 请求统一加监控、日志,或改写请求目标等

  • 给 Agent 划能力边界,只让它用特定身份(用户或应用)调用特定命令

  • 统一审计所有命令的执行情况

  • 写类命令接审批 / 限流后才放行

  • 进程启动时初始化资源、退出前刷盘清理

你只需要:写一个 Go 包,实现指定接口,在 wrapper main 里 import 它,重新编译——就得到一个行为完全不同的增强版 CLI。

image.png

飞书 CLI 提供六个扩展点,分为基础能力扩展和命令行为扩展两大类。基础能力扩展改变 CLI 底层的凭证获取和网络通信方式;命令行为扩展控制命令的可用范围、运行时行为和进程生命周期。

扩展点 分层定位 核心能力 使用场景
Credential 基础能力 · 凭证层 自定义凭证来源,让 CLI 从数据库、Vault、配置中心等任意来源获取 AppID / Token 内嵌 CLI 到自有系统,需要对接企业凭证管理
Transport 基础能力 · 网络层 拦截 CLI 每个 HTTP 请求,在请求前后注入 Header、改写目标、记录日志、上报监控 统一网络监控、日志审计、请求路由改写
Restrict 命令行为 · 裁剪层 声明命令白名单 / 黑名单 / 风险上限 / 身份限定,启动时将不合规命令改写为桩 给 Agent 划能力边界,只暴露安全的命令子集
Observer 命令行为 · Hook 层 命令前/后旁观,只看不拦;panic 安全,不影响命令执行 审计日志、埋点、命令监控(含被拒命令)
Wrap 命令行为 · Hook 层 洋葱模式中间件,可在命令前后做事,也可直接拦截(返回 AbortError) 审批拦截、限流、运行时 token 注入
On 命令行为 · 生命周期层 进程级 Startup / Shutdown 处理器,启动时初始化、退出前清理 初始化连接池、退出前刷审计日志

扩展 1:Credential — 凭证来源扩展

它是什么

Credential 扩展让你自定义凭证的来源。CLI 默认会从环境变量(LARKSUITE_CLI_APP_ID、LARKSUITE_CLI_APP_SECRET、LARKSUITE_CLI_USER_ACCESS_TOKEN、LARKSUITE_CLI_TENANT_ACCESS_TOKEN)或本地 keychain 读取凭证。通过注册自定义 Provider,你可以让 CLI 从数据库、Vault、配置中心等任意来源获取凭证。本文为了直观,就采取从本地读取凭证来进行演示。

怎么用

第 1 步:创建项目

mkdir my-cli && cd my-cli
go mod init my-cli

第 2 步:编写 Provider

创建 mycred/mycred.go,实现 credential.Provider 接口:

package mycred

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"

"github.com/larksuite/cli/extension/credential"
)

const (
    appID     = "cli_aXXXXXXXXXXXXXXXX" // 替换为真实 AppID
    appSecret = "XXXXXXXXXXXXXXXXXXXXXXXX" // 替换为真实 AppSecret
)

type Provider struct{}

func (p *Provider) Name() string { return "mycred" }

// ResolveAccount 返回应用凭证
// 返回 &Account{} → 命中,CLI 用这组凭证
// 返回 nil, nil   → 跳过,交给下一个 Provider
// 返回 nil, err   → 报错,终止
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
    // 这里替换成你自己的逻辑:查数据库、调 Vault、读配置中心...
    return &credential.Account{
        AppID:     appID,
        AppSecret: appSecret,
        Brand:     credential.BrandFeishu,
        DefaultAs: credential.IdentityUser,
    }, nil
}

// ResolveToken 返回访问令牌
// 重要:一旦你的 Provider 接管了 Account,Token 也由你负责。
// 返回 nil, nil 不会 fallback 到默认换 Token 逻辑,而是直接报错。
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
    switch req.Type {
    case credential.TokenTypeTAT:
        // bot 身份:用 AppID + AppSecret 向飞书换取 tenant_access_token
        token, err := exchangeTenantAccessToken(appID, appSecret)
        if err != nil {
            return nil, err
        }
        return &credential.Token{Value: token, Source: "mycred:tat"}, nil

case credential.TokenTypeUAT:
        // user 身份:从任意来源获取 user_access_token
        // 例如:从数据库查询、从 Redis 缓存读取、从 OAuth 回调获取、
        // 从配置文件读取,甚至直接写死一个用于测试...
        uat := getUserAccessToken()
        return &credential.Token{Value: uat, Source: "mycred:uat"}, nil
    }
    return nil, nil
}

// getUserAccessToken 从你的来源获取 UAT
// 这里你可以对接任何存储:数据库、Redis、文件、环境变量等
func getUserAccessToken() string {
    // 示例:从环境变量获取
    // return os.Getenv("MY_USER_ACCESS_TOKEN")

// 示例:从数据库查询
    // return db.Query("SELECT uat FROM tokens WHERE user_id = ?", userID)

// 示例:写死一个用于本地测试(从 lark-cli auth login 后获取)
    return "u-XXXXXXXX"
}

// exchangeTenantAccessToken 用 AppID + AppSecret 向飞书换取 tenant_access_token
func exchangeTenantAccessToken(appID, appSecret string) (string, error) {
    body, _ := json.Marshal(map[string]string{
        "app_id":     appID,
        "app_secret": appSecret,
    })
    resp, err := http.Post(
        //此处以feishu Brand作为演示,如果为LARK品牌host切换为:open.larksuite.com
        "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
        "application/json", bytes.NewReader(body),
    )
    if err != nil {
        return "", fmt.Errorf("exchange tat: %w", err)
    }
    defer resp.Body.Close()

var result struct {
        Code              int    `json:"code"`
        TenantAccessToken string `json:"tenant_access_token"`
    }
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return "", err
    }
    if result.Code != 0 {
        return "", fmt.Errorf("exchange tat failed, code=%d", result.Code)
    }
    return result.TenantAccessToken, nil
}

func init() {
    credential.Register(&Provider{})
}

第 3 步:编写 wrapper main

创建 main.go:

package main

import (
    "os"
    "github.com/larksuite/cli/cmd"

_ "my-cli/mycred"  // blank import 触发 init() → 注册你的 Provider
)

func main() {
    os.Exit(cmd.Execute())
}

第 4 步:编译验证

go mod tidy
go build -o my-lark-cli .

# 用 --dry-run 验证凭证来源(不会真正发请求)
./my-lark-cli api GET /open-apis/calendar/v4/calendars --dry-run

输出:

{
  "appId": "cli_a1234567890",
  "as": "user"
}

appId 显示的是你写死的值,说明自定义 Provider 生效了。

原理

Credential 采用链式数组存储多个 Provider,按注册顺序依次查询,第一个返回非 nil 的胜出:

import 顺序:  mycred → env
注册结果:     providers = [mycred, env]

CLI 需要凭证时:
  ┌─ mycred.ResolveAccount()
  │   查到了? → 返回 Account → 命中! 后面全跳过
  │   没查到? → 返回 nil    → 继续
  │
  ├─ env.ResolveAccount()
  │   环境变量有? → 返回 Account → 命中
  │   没有?      → 返回 nil    → 继续
  │
  └─ defaultAcct (keychain 兜底)

延迟执行:Provider 在启动时只是注册,不会立即被调用。只有当用户执行的命令真正需要凭证时,才会触发链式遍历。结果通过 sync.Once 缓存,整个进程只解析一次。

核心源码

文件 作用
extension/credential/types.go Provider 接口、Account / Token 类型定义
extension/credential/registry.go Register() 追加到全局数组,Providers() 返回快照
extension/credential/env/env.go 内置实现:从环境变量读取凭证

扩展 2:Transport — HTTP 请求拦截扩展

它是什么

Transport 扩展让你拦截 CLI 发出的每一个 HTTP 请求。你可以在请求发出前修改 Header、改写请求目标(Host / URL)、打印日志,在请求完成后记录耗时、上报监控。

怎么用

第 1 步:编写 Interceptor

创建 tracing/tracing.go,实现 transport.Provider 和 transport.Interceptor 接口:

package tracing

import (
    "context"
    "fmt"
    "net/http"
    "os"
    "time"

"github.com/larksuite/cli/extension/transport"
)

type Provider struct{}

func (p *Provider) Name() string { return "tracing" }

func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor {
    return &tracingInterceptor{}
}

type tracingInterceptor struct{}

// PreRoundTrip 在每个请求发出前被调用
// 返回的函数在请求完成后被调用
func (i *tracingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
    // ===== 请求前 =====
    start := time.Now()
    fmt.Fprintf(os.Stderr, "🚀 请求开始: %s %s\n", req.Method, req.URL)

// ===== 请求后(返回的闭包)=====
    return func(resp *http.Response, err error) {
        elapsed := time.Since(start)
        if err != nil {
            fmt.Fprintf(os.Stderr, "❌ 请求失败: %s %s err=%v (耗时 %v)\n",
                req.Method, req.URL, err, elapsed)
        } else {
            fmt.Fprintf(os.Stderr, "✅ 请求完成: %s %s 状态码=%d (耗时 %v)\n",
                req.Method, req.URL, resp.StatusCode, elapsed)
        }
    }
}

func init() {
    transport.Register(&Provider{})
}

第 2 步:在 main.go 中引入

package main

import (
    "os"
    "github.com/larksuite/cli/cmd"

_ "my-cli/tracing"  // blank import 注册 Transport Interceptor
    _ "github.com/larksuite/cli/extension/credential/env"
)

func main() {
    os.Exit(cmd.Execute())
}

第 3 步:编译验证

go build -o my-lark-cli .

验证 UAT(user 身份):

./my-lark-cli api GET /open-apis/calendar/v4/calendars --as user

输出:

🚀 请求开始: GET https://open.feishu.cn/open-apis/authen/v1/user_info
✅ 请求完成: GET https://open.feishu.cn/open-apis/authen/v1/user_info 状态码=200 (耗时 644ms)
🚀 请求开始: GET https://open.feishu.cn/open-apis/calendar/v4/calendars
✅ 请求完成: GET https://open.feishu.cn/open-apis/calendar/v4/calendars 状态码=200 (耗时 1070ms)
{ "code": 0, "data": { "calendar_list": [...] } }

验证 TAT(bot 身份):

./my-lark-cli api GET /open-apis/calendar/v4/calendars --as bot

输出:

🚀 请求开始: GET https://open.feishu.cn/open-apis/calendar/v4/calendars
✅ 请求完成: GET https://open.feishu.cn/open-apis/calendar/v4/calendars 状态码=200 (耗时 736ms)
{ "code": 0, "data": { ... } }

原理

Transport 采用单 Provider 模式,注册后会被包装为一个 HTTP 中间件,插入到 CLI 的请求链中:

CLI 发 HTTP 请求
      │
      ▼
你的 PreRoundTrip(req)         ← 请求前: 加 Header / 改写 Host / 打日志 / 开始计时
      │  返回一个 postFn
      ▼
CLI 内置 transport 链           ← Retry → SecurityHeader → 实际网络请求
      │
      ▼
postFn(resp, err)              ← 请求后: 记录耗时 / 上报监控

CLI 在构建 HTTP Client 时,会调用 wrapWithExtension() 把你的 Interceptor 包装成中间件。之后 CLI 发出的每一个请求(包括换 Token、调业务 API)都会经过这个中间件。

安全保障:CLI 内置安全层会统一重写基础安全 Header(如 X-Cli-*、User-Agent)。Authorization 不属于这层统一覆盖:它由请求构建阶段设置;如果发生跨 host redirect,会在 redirect 策略中被剥离。因此你可以添加自定义 Header,但不要依赖 Transport 扩展去覆盖凭证头。

核心源码

文件 作用
extension/transport/types.go Provider / Interceptor 接口定义
extension/transport/registry.go Register() 注册单个 Provider(last-write-wins)

扩展 3:Restrict — 命令裁剪(启动期)

它是什么

Restrict 让你声明一条或多条"裁剪规则"(Rule):哪些命令能用、哪些不能、最高到什么风险、限定什么身份。框架启动时把整棵命令树过一遍——不符合规则的命令被改写成桩:从 help 里隐藏,一旦执行就返回 command_denied。对命令分发来说它们还是合法命令,只是一跑就报错。

怎么用

platform.NewPlugin("secaudit", "1.0.0").
    Restrict(&platform.Rule{
        Name:    "agent-docs-readonly",
        Allow:   []string{"docs/**"}, // glob,"/" 是分隔符,"**" 匹配多段
        MaxRisk: "read",              // 风险上限:read | write | high-risk-write
    }).
    MustBuild()

编译后可以查看当前生效的裁剪规则:

$ ./my-lark-cli config policy show
{
  "source": "plugin",
  "source_name": "secaudit",
  "rules": [{"name": "agent-docs-readonly", "allow": ["docs/**"], "max_risk": "read", "allow_unannotated": false}],
  "denied_paths": 258
}

Rule 的字段

字段 含义 例子
Name 规则名(必填)。config policy show 输出和 envelope 的 rule_name 会带上它,方便排查 "agent-docs-readonly"
Description 可选描述,仅供 config policy show 展示 "Agent 只读 docs 命令"
Allow 白名单 glob,命中即通过;空表示"不限制路径" ["docs/**", "im/+messages-send"]
Deny 黑名单 glob,命中即拒绝(优先于 Allow) ["**/+*-delete"]
MaxRisk 风险等级上限(read / write / high-risk-write)。也可用常量 platform.RiskRead / RiskWrite / RiskHighRiskWrite "read" 排除所有写类命令
Identities 限定身份白名单(user / bot)。空表示不按身份过滤 []platform.Identity{"user"} 排除 bot 类命令
AllowUnannotated 是否放行未标注 risk_level 的命令。默认 false(fail-closed):未标注命令直接拒并报 risk_not_annotated;改 true 则这类命令跳过 MaxRisk 检查 true(把第三方 plugin 的老命令平滑接入)

多条规则怎么组合(容易踩坑):

  • 单条 Rule 内的四个维度(Allow / Deny / MaxRisk / Identities)是 AND:必须同时满足才放行。

  • 多条 Rule 之间是 OR:命中任意一条的全部维度即放行。同一个 plugin 可以多次调 Restrict,每条是一个独立授权。

  • 每条 Rule 的 Deny 只在本条内生效,不能否决另一条的 Allow。要全局禁某命令,得在每条 Rule 里都 Deny(或都不 Allow)。

  • 只接受一个 plugin 贡献规则:启动期发现 2 个以上不同 plugin 都贡献了 Rule,直接 abort 整个 CLI(plugin_conflict / multiple_restrict_plugins),不做静默合并。

  • ~/.lark-cli/policy.yml 也能写规则,但优先级最低:只要程序里有插件自带了规则,这个 yaml 就不会被加载。

多条 Rule 示例

不同命令组要不同 risk / 身份:

platform.NewPlugin("secaudit", "1.0.0").
    Restrict(&platform.Rule{ // docs 只读、限 user 身份
        Name:       "docs-readonly",
        Allow:      []string{"docs/**"},
        MaxRisk:    platform.RiskRead,
        Identities: []platform.Identity{"user"},
    }).
    Restrict(&platform.Rule{ // im 可写、user+bot 都允许
        Name:       "im-write",
        Allow:      []string{"im/**"},
        MaxRisk:    platform.RiskWrite,
        Identities: []platform.Identity{"user", "bot"},
    }).
    MustBuild()

命令命中任意一条的全部维度即放行;全都没命中时 envelope 报 no_matching_rule(只有一条 Rule 时仍报各自具体的 reason_code)。

诊断:命令为什么被拒?

两个只读、不调任何 API 的诊断命令,排查时先用它们:

命令 关注 数据
config policy show "当前生效裁剪规则是啥" Rule + denied_paths + source
config plugins show "都装了哪些 plugin、各自贡献啥" 完整 plugin 清单 + 各自 hook 列表

config policy show

查看 CLI 当前生效的裁剪规则、来源和被裁路径数。适用于:排查"为什么我的命令被拒"、验证 plugin 是否成功贡献了 Rule、检查用户 yaml 是否被 plugin 影子覆盖。

$ ./my-cli config policy show
{
  "source": "plugin",
  "source_name": "secaudit",
  "rules": [{
    "name": "agent-docs-readonly",
    "description": "",
    "allow": ["docs/**"],
    "deny": null,
    "max_risk": "read",
    "identities": null,
    "allow_unannotated": false
  }],
  "denied_paths": 258
}

字段 含义
source 当前生效来源:"plugin" / "yaml" / "none"
source_name 来源标签:仅当 source 为 plugin 时是插件名;yaml 与 none 来源下为空(定位 yaml 配置路径请查 ~/.lark-cli/policy.yml)
rules 已决议、已落地的 Rule 列表(单条时数组长度为 1)
denied_paths 被裁的命令路径总数(含父组聚合)

config plugins show

列出所有成功装载的 plugin 及其完整贡献:能力声明、贡献的 Rule、注册的 Hook(Observer / Wrapper / Lifecycle)。适用于:拿到别人编译的二进制想知道"到底装了啥"、排查"为啥我的命令前后多了一堆日志"、审计某 plugin 注册了哪些 Wrapper / Lifecycle、验证 plugin 是否完整安装。

$ ./my-cli config plugins show
{
  "plugins": [
    {
      "name": "secaudit",
      "version": "1.0.0",
      "capabilities": { "restricts": true, "failure_policy": "FailClosed" },
      "rules": [{
        "name": "agent-docs-readonly",
        "allow": ["docs/**"], "deny": null,
        "max_risk": "read", "identities": null, "allow_unannotated": false
      }],
      "hooks": {
        "count": 5,
        "observers": [
          { "name": "secaudit.audit-pre",  "when": "Before" },
          { "name": "secaudit.audit-post", "when": "After"  }
        ],
        "wrappers": [ { "name": "secaudit.approval" } ],
        "lifecycle": [
          { "name": "secaudit.init",  "event": "Startup"  },
          { "name": "secaudit.flush", "event": "Shutdown" }
        ]
      }
    }
  ],
  "total": 1
}

如何辨认 hook 归属: 框架在注册时把 hookName 自动加上 plugin 名前缀,分隔符是 "."。所以 secaudit.boot 就是 secaudit plugin 注册的 boot hook。plugin 名禁用 ".",分隔无歧义。

扩展 4:Observer — 旁观 Hook(只看不拦)

它是什么

Observer 让你注册一个"观察者",命令执行前(Before)或后(After)跑你的代码。它不参与主流程、不能拦命令,它出错或 panic 都不影响命令本身,常用于审计、埋点、监控。

怎么用

platform.NewPlugin("secaudit", "1.0.0").
    // 命令运行前:记审计
    Observer(platform.Before, "audit-pre", platform.All(),
        func(ctx context.Context, inv platform.Invocation) {
            log.Printf("pre  %s denied=%v", inv.Cmd().Path(), inv.DeniedByPolicy())
        }).
    // 命令运行后:记结果
    Observer(platform.After, "audit-post", platform.All(),
        func(ctx context.Context, inv platform.Invocation) {
            log.Printf("post %s err=%v", inv.Cmd().Path(), inv.Err())
        }).
    FailOpen().
    MustBuild()

Selector:决定 Hook 在哪些命令上生效

platform.All() 是"所有命令"。还有按维度筛选的 Selector:

platform.ByDomain("docs")     // docs 域所有命令
platform.ByExactRisk("write") // 风险等级精确为 write 的命令
platform.ByWrite()            // 风险为 write 或 high-risk-write
platform.ByReadOnly()         // 风险为 read 的命令
platform.ByIdentity("bot")    // 限定身份

用 .And() / .Or() / .Not() 组合:

// im 域,但排除 +chats-list
platform.ByDomain("im").And(platform.ByCommandPath("im/+chats-list").Not())

// docs 或 im 域(任一命中即可)
platform.ByDomain("docs").Or(platform.ByDomain("im"))

Observer 的三条边界

  • 不能短路:返回值会被丢弃,改变不了命令是否执行。

  • panic 安全:框架用 recover 包着,Observer panic 不会冒到外层。

  • 被裁命令也跑:inv.DeniedByPolicy() 为 true 时也会触发(这是 Observer 跟 Wrapper 最大的不同),方便审计被拒命令。

Invocation:Observer 和 Wrap 的运行期上下文

方法 返回 / 含义
Cmd() CommandView 命令视图,可调 .Path() / .Risk() / .Identities() / .Domain()
Args() []string 本次调用的位置参数(不含 flag)
Started() time.Time 命令开始时间,可在 After 里算耗时
Err() error 仅 After 有意义:命令 RunE 的返回错误(含 AbortError)
DeniedByPolicy() bool 是否被裁(user-layer Rule 或 strict-mode 都算)
DenialLayer() string 裁的来源:"policy" / "strict_mode";没被裁时为空
DenialPolicySource() string policy 层 Rule 的来源标签:plugin:<name> / yaml

扩展 5:Wrap — 中间件 Hook(能拦能改)

它是什么

Wrap 是"洋葱模式"中间件,像洋葱一样包住命令的真正逻辑。你拿到一个 next 函数,在调用 next 之前 / 之后做事,也可以直接拦下(返回 AbortError)。常用于审批、限流、注入 token。

Wrap 链按注册顺序左→右组合,外层先跑。

怎么用

经典场景:写类命令必须过审批。

.Wrap("approval", platform.ByWrite(),
    func(next platform.Handler) platform.Handler {
        return func(ctx context.Context, inv platform.Invocation) error {
            if !approvalClient.Check(ctx, inv.Cmd().Path()) {
                return &platform.AbortError{
                    HookName: "approval",
                    Reason:   "write requires approval",
                }
            }
            return next(ctx, inv)
        }
    })

效果:

{
  "ok": false,
  "error": {
    "type": "hook",
    "message": "hook \"secaudit.approval\" aborted: write requires approval",
    "detail": {
      "hook_name": "secaudit.approval",
      "reason": "write requires approval",
      "reason_code": "aborted"
    }
  }
}

多个 Wrapper 的组合顺序

注册顺序左→右,外层先跑:

.Wrap("logging",   All(),     loggingWrapper).
.Wrap("approval",  ByWrite(), approvalWrapper).
.Wrap("ratelimit", All(),     ratelimitWrapper)

logging → approval → ratelimit → 原 RunE

Observer vs Wrap:到底用哪个?

两者长得像(都靠 Invocation 拿上下文),但语义差很远。一句话:只想"看"用 Observer,要"拦 / 改"用 Wrap。

维度 Observer(旁观) Wrapper(中间件)
能拦下命令吗 不能,返回值被丢弃 能,返回 AbortError 即短路
能改命令是否执行吗 不能 能
panic 怎么处理 框架 recover,不影响命令 框架 recover,不污染外层
被裁(denied)的命令 照样跑(方便审计被拒命令) 不跑
触发时机 Before / After 两个点 包在命令真正逻辑外面(洋葱)
典型用途 审计、埋点、监控 审批、限流、token 注入

扩展 6:On — 生命周期(进程级)

它是什么

On 注册进程级生命周期处理器:

  • Startup:CLI 启动期、所有 plugin 装好、命令树定型后只跑一次。返错或 panic = 整树罢工(fail-closed)。

  • Shutdown:cmd.Execute 返回前跑。2 秒硬超时,错误只记录不上抛。

常用于初始化连接池、退出前刷盘。

怎么用

.On(platform.Startup, "init",
    func(ctx context.Context, lc *platform.LifecycleContext) error {
        return auditBuf.Open(ctx) // 启动期:加载审计 buffer
    }).
.On(platform.Shutdown, "flush",
    func(ctx context.Context, lc *platform.LifecycleContext) error {
        return auditBuf.Flush(ctx) // 退出前:刷盘
    })

Startup 失败的影响: 任何命令都会拿到下面这个 envelope,直到把 Startup 错误修复。

{
  "ok": false,
  "error": {
    "type": "plugin_lifecycle",
    "message": "lifecycle hook \"secaudit.init\" failed: connect: connection refused",
    "detail": {
      "reason_code": "lifecycle_failed",
      "hook_name": "secaudit.init",
      "event": "startup"
    }
  }
}

LifecycleContext 字段

字段 含义
Event LifecycleEvent 当前事件:Startup / Shutdown。一个 handler 注册多个 event 时用来分流
Err error 仅 Shutdown 有意义:cmd.Execute() 返回的命令错误。可据此区分"正常退出"与"异常退出"

扩展 1 及扩展 2 完整示例

将 Credential + Transport 两个基础扩展组合在一起的 wrapper main:

package main

import (
    "os"

"github.com/larksuite/cli/cmd" 

// 自定义扩展(写在前面 = 凭证优先级更高)
    _ "my-cli/mycred"    // 自定义凭证来源
    _ "my-cli/tracing"   // HTTP 请求拦截

// 原版 CLI 兜底
    _ "github.com/larksuite/cli/extension/credential/env"
)

func main() {
    os.Exit(cmd.Execute())
}

对比原版 CLI 的 main.go,只多了 2 行 import。业务代码 cmd.Execute() 完全不变,编译出来就是一个功能完整的增强版 CLI。

项目结构

my-cli/
├── go.mod             # require github.com/larksuite/cli v1.0.6
├── main.go            # Wrapper main
├── mycred/
│   └── mycred.go      # Credential: 自定义凭证来源
└── tracing/
    └── tracing.go     # Transport: HTTP 请求拦截

构建和运行

mkdir my-cli && cd my-cli
go mod init my-cli
# 编写 main.go、mycred/mycred.go、tracing/tracing.go(见上方代码)
go get github.com/larksuite/cli@v1.0.6
go mod tidy
go build -o my-lark-cli .

# 验证凭证扩展(--dry-run 不发真实请求,只看用了哪组凭证)
./my-lark-cli api GET /open-apis/calendar/v4/calendars --dry-run

输出:

{
  "appId": "cli_aXXXXXXXXXXXXXXXX",
  "as": "user"
}

# 验证请求拦截(发送真实请求,查看日志输出)
./my-lark-cli api GET /open-apis/calendar/v4/calendars --as user

输出:

🚀 请求开始: GET https://open.feishu.cn/open-apis/authen/v1/user_info
✅ 请求完成: GET .../user_info 状态码=200 (耗时 644ms)
🚀 请求开始: GET https://open.feishu.cn/open-apis/calendar/v4/calendars
✅ 请求完成: GET .../calendars 状态码=200 (耗时 1070ms)
{ "code": 0, "data": { "calendar_list": [...] } }

两个扩展同时生效:Credential 提供了凭证和 Token,Transport 拦截并记录了每个请求。

附录 A:进阶能力-更精细化的 hook 能力

前面 Restrict / Observer / Wrap / On 都用 Builder 写法。Builder 其实是对下面底层接口的封装。需要在结构体上挂依赖(审批客户端、连接池)、或 Install 逻辑复杂时,直接实现接口更清晰。每个 plugin 必须实现 4 个方法:

type Plugin interface {
    Name() string               // 全局唯一,正则 ^[a-z0-9][a-z0-9-]*$,禁用 "."
    Version() string            // 任意字符串,仅用于诊断
    Capabilities() Capabilities // 安全声明
    Install(r Registrar) error  // 启动期被调一次,在这里通过 r 注册具体能力
}

Capabilities 是安全契约,每次写新 plugin 必须明确填写:

type Capabilities struct {
    Restricts          bool          // 我会不会调 r.Restrict(命令裁剪)?默认 false
    FailurePolicy      FailurePolicy // FailOpen(装失败就跳过)/ FailClosed(装失败整树 abort)
    RequiredCLIVersion string        // 例如 ">=1.0.0";不满足按 FailurePolicy 处理
}

硬性约束: Restricts=true 必须配 FailurePolicy=FailClosed。框架一致性检查不通过会无条件 abort(即使声明 FailOpen)——安全相关的 plugin 不应该被 FailOpen 静默跳过。 用 Builder 就不用操心这些:NewPlugin(...).Restrict(...) 会自动把 Restricts=true 和 FailClosed 配齐。

Builder vs 接口实现,怎么选:

  • 简单 plugin(只挂 Observer / Wrap)→ 用 Builder,一行一个能力,零样板

  • 持有外部依赖(审批客户端、连接池)→ 实现 interface 让依赖落在 struct 字段上更清晰

Builder 还提供 FailOpen() / FailClosed() 显式 setter:只挂 Observer / Wrap 不调 Restrict 的 plugin 默认 FailOpen,但你可能想强制 FailClosed(plugin 装失败整树罢工,保证审计不丢)。一旦调过 Restrict(),Builder 会强制 FailClosed,再调 FailOpen() 会在 Build() 阶段报错。

附录 B:错误对照表

所有命令拒绝都走 command_denied,detail.layer 区分来源——policy 为用户层 Rule,strict_mode 为内置 strict-mode 裁剪。

错误类型 错误码 含义 怎么修
plugin_install restricts_mismatch Restricts=true 但 FailurePolicy≠FailClosed 改成 FailurePolicy: platform.FailClosed
plugin_install invalid_capability
FailurePolicy 不是 FailOpen/FailClosed,或 RequiredCLIVersion 格式非法 用合法枚举值;version 用单段 semver(≤3 段)
plugin_install invalid_plugin_name Name 不符合 ^[a-z0-9][a-z0-9-]*$ 改名(小写、连字符、不含点)
plugin_install duplicate_plugin_name 两个 plugin 用了同一个 Name 改名让其中一个唯一
plugin_install plugin_name_panic Plugin.Name() 在调用中 panic 排查 Name();不要做 I/O 或访问可能 nil 的字段
plugin_install capabilities_panic Plugin.Capabilities() panic 同上
plugin_install capability_unmet RequiredCLIVersion 不满足 升级 CLI 或放宽约束
plugin_install install_panic Install 里 panic 了 排查 Install 函数体
plugin_install install_failed Install 返回非 nil 的 error 看 envelope detail 里包裹的原始 error
plugin_install invalid_rule Rule 字段非法(如 max_risk 不在枚举内、glob 解析失败) 校验 Rule 字段;MaxRisk 用 read/write/high-risk-write
plugin_install invalid_hook_name hookName 不符合 ^[a-z0-9][a-z0-9-]*$(不能含 ".") 改名;框架自动加 plugin 名前缀
plugin_install duplicate_hook_name 同一 plugin 注册了两个同名 hook 改名让其唯一
plugin_install invalid_hook_registration Wrapper factory 返回 nil 或 selector 不合法 确保 Wrapper 构造返回非 nil Handler
plugin_conflict multiple_restrict_plugins 2+ 不同 plugin 都贡献 Rule(同一 plugin 多条不算冲突) 只保留一个贡献 Rule 的 plugin
plugin_lifecycle lifecycle_failed / lifecycle_panic Startup handler 返错 / panic 排查 Startup 逻辑
command_denied domain_not_allowed 命令不在 Allow 里 加进 Allow 或换条命令
command_denied command_denylisted 命令命中 Deny 列表 从 Deny 移除或换命令
command_denied write_not_allowed 写类命令风险超过 MaxRisk 提高 MaxRisk 或不用该命令
command_denied risk_too_high 命令风险超过 MaxRisk(非写类,保留位) 提高 MaxRisk
command_denied risk_not_annotated 命令未标注 risk_level,Rule 默认 fail-closed。注:内置命令已全部标注(commit 5d9d438),该错误主要出现在第三方 plugin 注册的命令 给命令补 risk_level 注解,或 Rule 设 AllowUnannotated=true
command_denied risk_invalid risk_level 拼错(如 wrtie),不在枚举内 改成合法枚举(错误信息含 did-you-mean 提示)
command_denied identity_mismatch 命令身份与 Rule.Identities 不交集 放宽 Identities 或换合适身份
command_denied no_matching_rule 注册了多条 Rule,命令一条都没命中(单条时仍报各自具体 reason_code) 放宽某条 Rule,或新增一条覆盖该命令的 Rule
command_denied identity_not_supported strict-mode 层裁命令:命令支持的身份与当前 strict-mode 不交集 调整 config strict-mode 或选支持当前身份的命令
command_denied all_children_denied 父组下所有子命令全部被拒,父节点也自动被裁(聚合) 放宽该域的 Allow 或允许部分子命令
command_denied mixed_children_strict_mode 父组下部分子命令被 strict-mode 裁;父节点聚合显示,detail.layer = strict_mode 看具体子命令的拒绝详情;通常是 strict-mode 配置问题
command_denied mixed_children_policy 父组下部分子命令被 user-layer policy 裁;父节点聚合显示,detail.layer = policy 放宽该域的 Allow / Deny 或允许特定子命令
hook aborted
Wrap 体内显式返回 *AbortError 短路 看 envelope detail 的 hook_name + reason
hook panic Wrap 体内 panic 被框架兜底捕获 修复 Wrapper 代码;框架已 recover,不会污染外层